package com.superafroman.utils.scanner;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FilenameFilter;
import java.io.InputStream;
import java.net.URI;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.List;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;

import org.apache.commons.lang.StringUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import com.superafroman.utils.scanner.criteria.IClassCriteria;
import com.superafroman.utils.scanner.criteria.IPackageCriteria;
import com.superafroman.utils.scanner.criteria.IPackageCriteria.Match;
import com.superafroman.utils.scanner.locater.IClassLocater;

/**
 * <p>
 * Scans the class path searching for classes that match certain criteria.
 * </p>
 * <p>
 * Criteria are made up of {@link IClassCriteria} and {@link IPackageCriteria}.
 * <p>
 * Packages will only be scanned for classes if they fit all the specified
 * {@link IPackageCriteria}. If there is a partial match on any of the
 * {@link IPackageCriteria} then sub-packages of the package will be scanned but
 * the packages classes will not be.
 * </p>
 * <p>
 * If a class fits all the {@link IClassCriteria} then it will be added to the
 * {@link List} of returned classes.
 * </p>
 */
public class ClassScanner {

    private static final Log LOG = LogFactory.getLog(ClassScanner.class);

    private List<IClassCriteria> classCriteria;

    private List<IPackageCriteria> packageCriteria;

    private List<IClassLocater> classLocaters;

    private FilenameFilter classFilter;

    private FilenameFilter directoryFilter;

    private FilenameFilter jarFilter;

    public ClassScanner() {

        classCriteria = new ArrayList<IClassCriteria>();
        packageCriteria = new ArrayList<IPackageCriteria>();

        classLocaters = new ArrayList<IClassLocater>();

        classFilter = new ClassFilter();
        directoryFilter = new DirectoryFilter();
        jarFilter = new JarFilter();
    }

    /**
     * Add a {@link IClassCriteria} to filter the scanned classes.
     *
     * @param criteria
     *         The {@link IClassCriteria} to set.
     */
    public void addClassCriteria(IClassCriteria criteria) {
        classCriteria.add(criteria);
    }

    /**
     * Add a {@link IClassLocater} to find classes to search.
     *
     * @param locater
     *         The {@link IClassLocater} to set.
     */
    public void addClassLocater(IClassLocater locater) {
        classLocaters.add(locater);
    }

    /**
     * Add a {@link IPackageCriteria} to filter the scanned packages.
     *
     * @param criteria
     *         The {@link IPackageCriteria} to set.
     */
    public void addPackageCriteria(IPackageCriteria criteria) {
        packageCriteria.add(criteria);
    }

    /**
     * Find all classes on the class path that match the specified criteria.
     *
     * @return
     *         A {@link List} of {@link Class}es that match all the given
     *         criteria.
     */
    public List<Class<?>> findClasses() {

        List<Class<?>> classes = new ArrayList<Class<?>>();

        for (IClassLocater locater : classLocaters) {

            for (URI locationUri : locater.getClassLocations()) {

                File location = new File(locationUri);

                if (location.exists()) {
                    if (location.isDirectory()) {
                        classes.addAll(scanDirectory(location, ""));
                    } else {
                        classes.addAll(scanFile(location, ""));
                    }
                }
            }
        }

        return classes;
    }

    /**
     * Checks if file fits all {@link IClassCriteria}.
     *
     * @param classFile
     *         The file of the class to scan.
     *
     * @param packageName
     *         The package name of the class.
     *
     * @return
     *         <code>null</code> if the class does not fit the criteria, the
     *         {@link Class} object otherwise.
     */
    private Class<?> scanClass(File classFile, String packageName) {

        InputStream classInputStream;
        try {
            classInputStream = new FileInputStream(classFile);
        } catch (FileNotFoundException e) {
            LOG.warn("Couldn't get InputStream for classFile. classFile = "
                    + classFile);
            return null;
        }

        // create fully qualified class name
        String className = StringUtils.isEmpty(packageName) ? "" : packageName
                + ".";
        className += classFile.getName().substring(0,
                classFile.getName().indexOf("."));

        for (IClassCriteria criteria : classCriteria) {
            if (!criteria.fitsCriteria(className, classInputStream)) {
                return null;
            }
        }

        try {
            return Class.forName(className);
        } catch (ClassNotFoundException e) {
            LOG.warn("Class that fits criteria found but could not be loaded. className = "
                     + className);
            return null;
        }
    }

    /**
     * Scan a directory for classes that match certain {@link IClassCriteria}.
     * This will recurse into sub-directories provided they match the
     * {@link IPackageCriteria}.
     *
     * @param pathElement
     *         The current directory we are in.
     * @param packageName
     *         The package name we are currently in. i.e. "com.superafroman".
     * @return
     *         A {@link List} of {@link Class}es that match all the given
     *         criteria.
     */
    private List<Class<?>> scanDirectory(File pathElement, String packageName) {

        List<Class<?>> matched = new ArrayList<Class<?>>();

        if (StringUtils.isEmpty(packageName)) {
            File[] jars = pathElement.listFiles(jarFilter);
            for (File jar : jars) {
                matched.addAll(scanJar(jar));
            }
        }

        Match minMatch = Match.COMPLETE;

        for (IPackageCriteria criteria : packageCriteria) {
            Match match = criteria.fitsCriteria(packageName);

            if (match.compareTo(minMatch) < 0) {
                minMatch = match;
            }

            if (minMatch == Match.NONE) {
                break;
            }
        }

        // if match is either partial or complete then scan directories
        if (minMatch.compareTo(Match.PARTIAL) >= 0) {

            File[] directories = pathElement.listFiles(directoryFilter);

            for (File directory : directories) {
                matched.addAll(scanDirectory(directory, StringUtils
                        .isEmpty(packageName) ? directory.getName()
                        : packageName + "." + directory.getName()));
            }
        }
        // if match is complete then scan classes
        if (minMatch == Match.COMPLETE) {

            File[] classes = pathElement.listFiles(classFilter);

            for (File classFile : classes) {

                Class<?> clazz = scanClass(classFile, packageName);

                if (clazz != null) {
                    matched.add(clazz);
                }
            }
        }

        return matched;
    }

    /**
     * Scan a file. Passes the actual scanning on to scanClass or scanJar
     * depending on the file suffix.
     *
     * @param file
     *         The file to scan.
     *
     * @return
     *         A {@link List} of {@link Class}es that match all the
     *         given criteria.
     */
    private List<Class<?>> scanFile(File file, String packageName) {

        List<Class<?>> matched = new ArrayList<Class<?>>();

        if (file.getName().endsWith(".class")) {

            Class<?> clazz = scanClass(file, packageName);

            if (clazz != null) {
                matched.add(clazz);
            }
        } else if (file.getName().endsWith(".jar")) {
            matched.addAll(scanJar(file));
        }

        return matched;
    }

    /**
     * Scan a jar. Goes through each entry and checks package criteria
     * and class criteria.
     *
     * @param file
     *         The file to scan.
     *
     * @return
     *         A {@link List} of {@link Class}es that match all the
     *         given criteria.
     */
    private List<Class<?>> scanJar(File file) {

        List<Class<?>> matched = new ArrayList<Class<?>>();

        try {
            JarFile jar = new JarFile(file);
            JarEntry entry;

            Enumeration<JarEntry> entries = jar.entries();

            loop:
            while (entries.hasMoreElements()) {

                entry = entries.nextElement();
                String name = entry.getName();

                if (name.endsWith(".class") && name.indexOf("$") == -1) {

                    String packageName = name.substring(0,
                            name.lastIndexOf("/")).replace("/", ".");
                    Match bestMatch = Match.NONE;

                    for (IPackageCriteria criteria : packageCriteria) {
                        Match match = criteria.fitsCriteria(packageName);

                        if (match.ordinal() > bestMatch.ordinal()) {
                            bestMatch = match;
                        }

                        if (bestMatch == Match.COMPLETE) {
                            break;
                        }
                    }

                    if (bestMatch != Match.COMPLETE) {
                        continue loop;
                    }
                    InputStream classInputStream = jar.getInputStream(entry);

                    String className = name.replace("/", ".").substring(0,
                            name.lastIndexOf("."));

                    for (IClassCriteria criteria : classCriteria) {
                        if (!criteria.fitsCriteria(className, classInputStream)) {
                            continue loop;
                        }
                    }

                    matched.add(Class.forName(className));
                }
            }
        } catch (Exception e) {
        }

        return matched;
    }

    /**
     * Basic implementation of {@link FilenameFilter} that only includes class
     * files.
     */
    private class ClassFilter implements FilenameFilter {

        public boolean accept(File dir, String name) {
            return name.endsWith(".class") && name.indexOf("$") == -1;
        }
    }

    /**
     * Basic implementation of {@link FilenameFilter} that only includes
     * directories.
     */
    private class DirectoryFilter implements FilenameFilter {

        public boolean accept(File dir, String name) {
            File test = new File(dir.getAbsolutePath() + File.separator + name);
            return test.isDirectory();
        }
    }

    /**
     * Basic implementation of {@link FilenameFilter} that only includes jar
     * files.
     */
    private class JarFilter implements FilenameFilter {

        public boolean accept(File dir, String name) {
            return name.endsWith(".jar");
        }
    }
}
